Skip to content

Traffic ctl plugin list format - #13626

Open
brbzull0 wants to merge 3 commits into
apache:masterfrom
brbzull0:traffic-ctl-plugin-list-format
Open

brbzull0 wants to merge 3 commits into
apache:masterfrom
brbzull0:traffic-ctl-plugin-list-format

Conversation

@brbzull0

@brbzull0 brbzull0 commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

traffic_ctl: honor -f json for plugin list

TL;DR

traffic_ctl plugin list -f json printed the human-readable table and no JSON.
The flag was parsed, a printer was constructed, the server returned a correct
payload — the command just never consulted the printer on the success path.

Given a plugin.yaml:

plugins:
  - path: stats_over_http.so
    load_order: 10
  - path: xdebug.so
    params:
      - --enable=x-cache
  - path: header_rewrite.so
    enabled: false

both of these printed the same thing:

$ traffic_ctl plugin list
source: plugin.yaml
  #  plugin                          load_order   status
  1  stats_over_http.so              10           loaded
  2  xdebug.so                       --           loaded
  3  header_rewrite.so               --           disabled

$ traffic_ctl plugin list -f json
source: plugin.yaml
  #  plugin                          load_order   status
  1  stats_over_http.so              10           loaded
  2  xdebug.so                       --           loaded
  3  header_rewrite.so               --           disabled

$ traffic_ctl plugin list -f json | python3 -c 'import json,sys; json.load(sys.stdin)'
json.decoder.JSONDecodeError: Expecting value: line 1 column 1 (char 0)

Column 1, char 0 — no JSON at all, not a malformed payload.

The load_order and enabled: false columns above only exist because of the
recent plugin.yaml migration, which makes the payload worth consuming
programmatically in a way the old single-line plugin.config never was.

This moves the table into a PluginListPrinter so the command follows the same
path as every other one, and adds the autests that were impossible to write
before.

Text output is unchanged, byte for byte.


⚠️ Stacked on #13609 — please merge that one first

This branch is built on top of
#13609
(traffic_ctl: emit JSON null instead of YAML tilde), so GitHub shows five
commits and fourteen files
. Only the top two are mine:

Commit Belongs to
Add autests for traffic_ctl plugin list output this PR
traffic_ctl: honor -f json for plugin list this PR
traffic_ctl: address review on the JSON emitter helper #13609
traffic_ctl: route JSON emitters through one helper #13609
traffic_ctl: emit JSON null instead of YAML tilde #13609

Everything under src/config/, src/mgmt/, include/, and
doc/developer-guide/jsonrpc/ in this diff belongs to #13609 — including the
one-line YAML::NodeType::Sequence change in
src/mgmt/rpc/handlers/plugins/Plugins.cc. This PR's own changes are limited
to src/traffic_ctl/ and tests/gold_tests/traffic_ctl/.
Reviewing those
two directories covers it.

Note this is not the plugin.yaml migration — that is already on master and
is untouched here. #13609 is about the JSON emitter writing ~ where JSON
requires null.

Why stacked rather than standalone. On master the server emits plugins: ~
for an empty plugin list, which no JSON parser accepts. So
traffic_ctl_plugin_empty.test.py cannot assert a successful parse until
#13609 lands its LowerNull emitter change together with the
YAML::NodeType::Sequence fix in get_plugin_list. The alternative was to
copy that one-line fix into this branch, which would have duplicated an open PR
and set up a merge conflict between the two. Stacking makes the dependency
visible instead of hiding it.

If reviewers would rather see this stand alone, the empty-plugin-list test can
move into #13609 — that PR is what makes the output parseable, so the
assertion arguably belongs there — leaving this PR as the printer fix plus the
populated-config test, both of which pass on master unchanged.


The bug

PluginCommand::plugin_list() touched _printer exactly once, in the error
branch. Past that it decoded the response and hand-rolled a table into
std::cout:

if (response.is_error()) {
  _printer->write_output(response);   // the only use of _printer
  return;
}

auto info = response.result.as<PluginListResponse>();

std::cout << "source: " << info.source << '\n';   // hand-rolled from here down
...

Format-agnostic by construction — every format produced byte-identical output,
whether the source was plugin.yaml or the legacy plugin.config.

-f rpc appeared to work, which made this easy to miss: the wire trace is
emitted by the transport layer through _printer->write_debug(), independently
of whatever the command itself prints.

Every other command hands the response to the printer and lets
BasePrinter::write_output(JSONRPCResponse const &) branch on
is_json_format() — emitting the envelope for JSON, delegating to the derived
write_output(YAML::Node const &) for text. plugin list was the only one
bypassing it.

The fix

Three files, and the table code moves verbatim:

  • CtrlPrinters.h — new PluginListPrinter, alongside the fifteen printers
    already there.
  • CtrlPrinters.cc — the table loop, unchanged, as
    PluginListPrinter::write_output(YAML::Node const &). <iomanip> moves here
    with it.
  • CtrlCommands.cc — pick the printer per subcommand; plugin_list()
    reduces to build, invoke, hand off.
void
PluginCommand::plugin_list()
{
  GetPluginListRequest request;
  auto                 response = invoke_rpc(request);

  _printer->write_output(response);
}

JSON then works through the base class. The explicit response.is_error()
early-out disappears with no behavior change: the old code passed the response
to a GenericPrinter, which resolves to the same non-virtual
BasePrinter::write_output(JSONRPCResponse const &) the new code calls.

Exit codes are unaffected, including on error. Worth noting for reviewers that
BasePrinter::write_output sets App_Exit_Status_Code = CTRL_EX_ERROR only in
text mode — with -f json it emits fullMsg and returns before the
is_error() block, so an RPC error still exits CTRL_EX_OK. That is
pre-existing and global to traffic_ctl, unchanged here, and orthogonal to
this PR.

The --format flag is documented as a global option with no per-command
carve-out, so this brings the code in line with documented behavior rather than
adding a new capability.

Why text mode keeps the table

HostDBStatusPrinter and ServerStatusPrinter both just call
write_output_json(result["data"]) in text mode — they have no human format
at all, so bare traffic_ctl server status already prints JSON. plugin list
is the only command in this family with a real table, and dropping it to match
would be a user-visible regression for no gain. After this change text is for
humans and -f json is for machines.

Tests

Two autests, and the assertion is a real parse, not a gold file. That
distinction is the whole point: a gold file would have matched the table
indefinitely, which is exactly how both the ignored --format flag and the
~ shipped green.

  • traffic_ctl_plugin_output.test.py — populated plugin.config. Asserts the
    text table byte for byte, that -f json parses, and that
    result.data.source is correct.
  • traffic_ctl_plugin_empty.test.py — empty plugin.config. Asserts
    plugins is [], which is the assertion that needs traffic_ctl: emit JSON null instead of YAML tilde #13609 underneath it.
    Separate file because a populated config never reaches this case, and
    because TrafficCtl hardcodes its ATS process name, so two instances cannot
    share one file.

Both use plugin.config rather than plugin.yaml: the autest ATS extension
registers plugin.config as a Disk file but has no plugin.yaml equivalent,
so the DSL cannot write one. This leaves the load_order column and the
disabled status uncovered — both are reachable only through plugin.yaml.
Adding that registration is a reasonable follow-up; it is not needed to prove
the format dispatch works, which is what this PR changes.

The DSL had no plugin() builder, so this adds one, plus a plugin_config
parameter mirroring the existing records_yaml, and two assertion helpers:

  • validate_json_parses() — pipes through json.load and asserts exit 0.
  • validate_json_data_contains() — same comparison as the existing
    validate_json_contains, but descends into result.data first. The existing
    helper only reaches top-level keys, and with -f json those are just
    jsonrpc, result and id, so payload fields were unreachable.

The new helper keeps its inline script single quoted and passes expected values
as one shlex.quote'd JSON argument. The existing validate_json_contains
interpolates them straight into a double-quoted shell word, so a value
containing an apostrophe raises SyntaxError, one containing $ is silently
shell-expanded before the comparison, and $(...) executes. Not fixed here to
keep the diff scoped, but it is a live footgun in that helper.

Verified the tests are a real regression guard rather than passing for the
wrong reason: feeding the captured pre-fix output to json.load reproduces
Expecting value: line 1 column 1 (char 0) and exits 1.

TOTAL: 2 passed, 0 failed, 0 skipped

Out of scope

-f yaml is not a format traffic_ctl supports. _Fmt_str_to_enum holds
only json and rpc, FormatFlags has no YAML member, and --format
documents {json|rpc}. parse_print_opts looks the string up and silently
keeps NOT_SET on a miss, so -f yaml — and any other unknown value — is
ignored on every command, not just this one. Rejecting unknown format values
is a separate change with wider blast radius.

Scalars are emitted as strings. The JSON emitter double-quotes every
scalar and YAML::Node has already lost the type, so enabled arrives as
"true" and index as "1". Pre-existing and global to traffic_ctl; the
existing validate_json_contains(initialized_done='true') assertion depends
on it.

Known coverage gaps. The load_order column and the disabled status are
untested: plugin.config hardcodes load_order = -1 for every entry, so the
wide header and the -- fallback are unreachable, and the autest ATS extension
registers no plugin.yaml Disk file for the DSL to write. The error path is
also untested — the is_error() early-out this PR removes has no autest
exercising it. Both are worth follow-ups.

admin_plugin_get_list is undocumented. It appears nowhere in
jsonrpc-api.en.rst, while its sibling admin_plugin_send_basic_msg is
referenced from the plugin msg entry. Left alone here; a doc-only follow-up.

Why it matters now

#13609 fixes traffic_ctl emitting YAML's ~ where JSON requires null, and
two commands hit it: hostdb status on an empty HostDB, and plugin list with
no plugins loaded. Its plugin list half could not be asserted, because
-f json produced no JSON to parse — that is the gap this PR closes, which is
why it sits on top rather than beside.

The plugin.yaml migration is the other reason this matters now. plugin list
exists to introspect a format that carries per-entry state — load_order,
enabled — and the whole point of a -f json on that command is letting
tooling read it. A flag that silently returns a fixed-width table instead
defeats that.

@brbzull0 brbzull0 added this to the 11.0.0 milestone Sep 3, 2026
@brbzull0 brbzull0 self-assigned this Sep 3, 2026
Copilot AI lite review requested due to automatic review settings September 3, 2026 11:04
@brbzull0 brbzull0 added Plugins JSONRPC JSONRPC 2.0 related work. labels Sep 3, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The moved table printer now uses snprintf() without an explicit header include (build fragility), and the new AuTest helper hardcodes python3 instead of using the harness interpreter.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes traffic_ctl plugin list so it honors -f json by routing the successful response through the configured printer (matching the behavior of other traffic_ctl commands), and adds AuTest coverage to ensure JSON output is parseable and stable.

Changes:

  • Add a PluginListPrinter and use it for traffic_ctl plugin list so JSON format works on the success path.
  • Centralize “JSON-compatible yaml-cpp emitter” configuration via ts::Yaml::configure_json_emitter() (incl. null emission).
  • Add gold tests and test utilities to validate both text output presence and JSON parsing/structure for plugin list output (including empty plugin lists).
File summaries
File Description
tests/gold_tests/traffic_ctl/traffic_ctl_test_utils.py Adds JSON result-data matcher helper and plugin command wrapper; supports injecting plugin.config lines into the ATS process.
tests/gold_tests/traffic_ctl/traffic_ctl_plugin_output.test.py New AuTest covering plugin list output in JSON and basic text mode smoke validation.
tests/gold_tests/traffic_ctl/traffic_ctl_plugin_empty.test.py New AuTest ensuring empty plugin list emits plugins: [] and JSON parses.
src/traffic_ctl/CtrlPrinters.h Declares new PluginListPrinter.
src/traffic_ctl/CtrlPrinters.cc Implements PluginListPrinter table rendering and switches JSON emission to shared emitter configuration.
src/traffic_ctl/CtrlCommands.cc Selects PluginListPrinter for the plugin list subcommand and routes output through _printer.
src/mgmt/rpc/handlers/plugins/Plugins.cc Ensures empty plugin lists are emitted as an empty sequence (not null).
src/mgmt/rpc/handlers/hostdb/HostDB.cc Ensures empty hostdb partition lists are emitted as an empty sequence (not null).
src/config/storage.cc Routes JSON emission through ts::Yaml::configure_json_emitter().
src/config/ssl_multicert.cc Routes JSON emission through ts::Yaml::configure_json_emitter().
include/tsutil/YamlCfg.h Introduces ts::Yaml::configure_json_emitter() to centralize JSON-ish yaml-cpp emitter configuration.
include/shared/rpc/yaml_codecs.h Uses ts::Yaml::configure_json_emitter() for request encoding output.
include/mgmt/rpc/jsonrpc/json/YAMLCodec.h Uses ts::Yaml::configure_json_emitter() and updates related documentation/comments.
doc/developer-guide/jsonrpc/jsonrpc-architecture.en.rst Documents null emission behavior (null vs ~) for JSON compatibility.
Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/traffic_ctl/CtrlPrinters.cc
Comment thread tests/gold_tests/traffic_ctl/traffic_ctl_test_utils.py Outdated
Copilot AI review requested due to automatic review settings September 3, 2026 11:23
@brbzull0
brbzull0 force-pushed the traffic-ctl-plugin-list-format branch from fae05ff to e8dfbee Compare September 3, 2026 11:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The change correctly restores format dispatch for plugin list without altering text output and adds targeted AuTests that validate real JSON parsing and payload structure.

Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@brbzull0

brbzull0 commented Sep 4, 2026

Copy link
Copy Markdown
Contributor Author

[approve ci autest]

@bryancall
bryancall requested a review from cmcfarlen September 14, 2026 21:58
cmcfarlen
cmcfarlen previously approved these changes Sep 21, 2026

@cmcfarlen cmcfarlen left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The printer refactor is behavior-preserving as far as I can tell — dropping the is_error() early-out is fine because BasePrinter::write_output(JSONRPCResponse const&) already handles it identically (error -> prints the error and sets CTRL_EX_ERROR in text mode, fullMsg in JSON mode), so no exit-code or error-path regression. Derived write_output gets the same response.result node the old code decoded, the table code moved verbatim, and -f json does parse at plugin list depth because --format is a root-level option and append_option_data scans and erases option tokens across the whole argv from the root — the same mechanism rpc invoke X -f json already relies on.

One non-blocking note on include/tsutil/YamlCfg.h:54: configure_json_emitter mixes emitter scopes. SetNullFormat(LowerNull) is global, but << YAML::DoubleQuoted << YAML::Flow are local manipulators that only apply to the group opened after they are issued. The comment ("Every emitter whose output reaches a JSON consumer must go through here") reads as order-independent, so a future caller who writes a token first — emitter << YAML::BeginMap; ts::Yaml::configure_json_emitter(emitter); — would get block style with unquoted scalars while nulls still render as null, i.e. silently non-JSON output that looks plausible. Every current caller passes a fresh emitter, so this is latent rather than live. A one-line "call this before emitting anything" in the comment (or an assert) would be enough.

plugin_list() formatted its table straight to std::cout and only
consulted the printer on the error path, so --format was silently
ignored on success and the output could not be consumed as JSON.

Move the table into a PluginListPrinter so the command follows the
same path as every other one: the base class dispatches on format,
emitting the JSON-RPC envelope for -f json and delegating to the
printer for text. Text output is unchanged.
Copilot AI review requested due to automatic review settings September 21, 2026 18:41
@brbzull0
brbzull0 force-pushed the traffic-ctl-plugin-list-format branch from e8dfbee to dcb7a8e Compare September 21, 2026 18:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🔵 Needs a closer look

The JSON validation helper may mask traffic_ctl exit-code failures by checking only the pipeline's parser status.

Review effort: Lite
Findings: None

Damian Meden added 2 commits September 22, 2026 10:48
Assert the payload through -f json rather than the text table. Column
widths are a presentation detail, so pinning them down byte for byte
only makes the test brittle against cosmetic changes. Text mode gets a
smoke check that it still renders a table.

The parse is the point. A gold file would have matched unparseable
output indefinitely; the command itself had no autest before this.

The empty plugin.config case gets its own test because a populated
config never reaches it, and because TrafficCtl hardcodes its ATS
process name, so two instances cannot share one file.

Adds the plugin_config parameter the DSL was missing. The payload is
asserted with validate_json_contains, naming the result node so the
check also fails if it ever carries a sibling of data.
This is the first caller to pass a nested expectation, and a nested
value compared as a single repr() pair on one line, with the actual in
emission order and the expected in source order, meant eyeballing two
long inline dicts to find the differing key. Serialise both sides the
same way, key-sorted, one per line.
@brbzull0
brbzull0 force-pushed the traffic-ctl-plugin-list-format branch from dcb7a8e to 08e0242 Compare September 22, 2026 10:05
Copilot AI review requested due to automatic review settings September 22, 2026 10:05
@brbzull0

Copy link
Copy Markdown
Contributor Author

Rebased onto master and force-pushed, so here is what changed and why.

The rebase. #13609 merged and shared its first three commits with this branch. A squash has no link to its originals, so git replayed already-landed code and reported conflicts in YAMLCodec.h, yaml_codecs.h, YamlCfg.h, CtrlPrinters.cc and jsonrpc-architecture.en.rst. Rebasing with those three commits dropped removes all of them. Two commits remain, plus one new one.

The test helper is gone. The approved version added validate_json_data_matches, which piped traffic_ctl into an inline python -c comparison. validate_json_contains's own docstring, in the same file, argues against exactly that: the exit status of a shell pipeline is the parser's, so a non-zero traffic_ctl exit never reaches the ReturnCode check. The helper sat about forty lines above that text and did the thing it warns about.

It was also redundant. All three handlers that write result()["data"] — HostDB.cc:198, Plugins.cc:118, Server.cc:204 — use data as the sole key of the result node, so validate_json_contains(result={'data': ...}) asserts the same thing and additionally fails if a sibling of data ever appears. Both call sites now use it.

Two smaller fixes in that commit: the separate validate_json_contains(jsonrpc='2.0') assertion is dropped, since the envelope version is already asserted byte-exactly in traffic_ctl_json_null.test.py and the result={'data': ...} expectation proves the envelope is emitted anyway; and the output test gains Test.SkipUnless(Condition.PluginExists(...)), which every other plugin-loading autest has — without it a missing .so reaches Fatal() in Plugin.cc:222 instead of skipping.

traffic_ctl_json_null.test.py is touched for the same reason: it carried Once plugin list honours -f json, add: ..., which this PR discharges, alongside a claim that validate_json_contains cannot reach result.data.plugins, which the spelling above disproves.

One new commit. traffic_ctl: render JSON field mismatches key-sorted changes _check_json_fields's failure output. This PR is the first caller to pass a nested expectation, and a nested mismatch rendered as a single repr() pair on one line — actual in emission order, expected in source order — meant comparing two ~220-character inline dicts to find the differing key. Both sides are now serialized the same way, key-sorted, one per line. It is a separate commit because it touches shared code this PR only uses rather than introduces; drop it if you would rather it went separately.

Unchanged: all three C++ files are byte-identical to the approved revision. Only the autests moved.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Copilot review overview

Review effort: Lite
Findings: None

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Bug JSONRPC JSONRPC 2.0 related work. Plugins

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants